Skip to content

[WC-3435]: Image Cropper Design and feats#2280

Open
rahmanunver wants to merge 21 commits into
mainfrom
feat/image-cropper-polish-rotate-bw
Open

[WC-3435]: Image Cropper Design and feats#2280
rahmanunver wants to merge 21 commits into
mainfrom
feat/image-cropper-polish-rotate-bw

Conversation

@rahmanunver

@rahmanunver rahmanunver commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Pull request type

New feature (non-breaking change which adds functionality)


Description

Initial development of the Image Cropper widget. This is a pre-release widget; the items below describe its current capabilities, not changes to a shipped version.

Cropping

  • Interactive crop with configurable shape (rectangle / circle) and aspect ratio (free or custom)
  • Zoom via inline slider and optional mouse-wheel zoom
  • Output configuration: format, quality, and size (original or viewport)

Toolbar

  • Single-row toolbar: flip-left, flip-right, grayscale, zoom slider, reset
  • Flip controls (90° rotation under the hood) rendered as SVG icon buttons
  • Grayscale toggle
  • Reset to the original image
  • Always-on native tooltips on every toolbar button

Editor experience (Studio Pro)

  • Structure-mode and design-mode previews
  • Design-mode preview renders the real crop area against the configured image
  • Studio Pro toolbox category and icons

Layout

  • Widget sizes to its image and renders block-level, so sibling widgets stack below it
  • Editor preview fills the available width

Behavior

  • Grayscale stays reversible after a flip; grayscale is baked into the saved file only when the toggle is on
  • Crop auto-commit is gated on a genuine user drag, so editing one cropper instance does not commit others in a list
  • Crop alignment preserved across flips via a live blob preview
  • Image source guarded through a safeImageUri allowlist

Testing

  • Unit/integration specs for flip, grayscale, zoom, crop commit, grayscale reversibility, multi-instance commit isolation, and editor previews (103 tests)

What should be covered while testing?

  • Crop, zoom, flip, grayscale, and reset on a bound image attribute
  • Grayscale ON -> flip -> grayscale OFF restores color; grayscale ON -> flip -> save with no further crop persists grayscale
  • Two croppers in a list: editing one and saving commits only that instance

@rahmanunver rahmanunver requested a review from a team as a code owner June 19, 2026 07:49
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@rahmanunver rahmanunver force-pushed the feat/image-cropper-polish-rotate-bw branch from 60f8a9b to 2741159 Compare June 25, 2026 08:15
rahmanunver and others added 15 commits June 29, 2026 15:29
… fix crop alignment

Replace CSS-transform rotation with a pixel re-bake so the crop selection
maps to the visible image, and add a blob-URL live preview so the rotation
is visible before the deferred Mendix commit (Save). Reset now restores the
true first-load original via an internal-change gate in useOriginalImage.
…esign mode

Simplify structure mode to a title row plus a single status/config row
([No attribute selected] vs config summary), dropping the icon and large
image render. Rewrite design mode into a three-state preview driven by the
bound image: static images render the real CropArea (non-interactive) with a
config caption, while dynamic/unbound images show a placeholder glyph with
[No image selected yet].

Extract shared describeConfig/aspectLabel into utils, add the cropper
placeholder asset, declare the png module for TypeScript, and cover both
editor surfaces with new specs.
…t fixes

Terminology:
- rename user-facing "Rotate" to "Flip" (toolbar, tooltips, enableFlip property)
- rename "Black and white"/"B&W" toggle to "Grayscale"

Toolbar:
- single-row layout: flip-left, flip-right, grayscale, zoom slider, reset
- flip buttons use SVG icons; square, icon-only styling
- always-on native tooltips on every toolbar button

Layout:
- size widget to its image and render block-level so sibling widgets stack below
- editor design-mode preview fills available width
- remove redundant "Image cropper" label above the design-mode preview

Behavior:
- keep a color working image on flip so grayscale stays reversible; bake
  grayscale only into the committed file when the toggle is on
- gate crop auto-commit on a genuine user drag so editing one cropper does not
  commit sibling instances in a list

Tests: rename specs for flip/grayscale; add grayscale-reversibility and
multi-instance commit regression specs.
@r0b1n r0b1n force-pushed the feat/image-cropper-polish-rotate-bw branch from 2741159 to 1472687 Compare June 29, 2026 15:05
@github-actions

This comment has been minimized.

Remove keepSelection and add MIN_CROP_PX min width/height so dragging on
empty canvas starts a fresh selection instead of nudging the old one.

A bare click (mousedown+mouseup, no drag) yields a ~0-size crop from
react-image-crop that minWidth/minHeight does not catch, which would wipe
the box. Guard it with isStrayCrop: crops below MIN_DRAW_FRACTION of the
displayed image are ignored so the existing selection survives.
Reset previously cleared the selection, leaving no box. Restore the
original bytes and re-seed the 80%-centered default via the extracted
buildInitialCrop (now in utils/initialCrop). Two paths: when restoring
changed the uri, CropArea's onLoad re-seeds against the correct
dimensions; when nothing was edited the uri is unchanged and onLoad
won't refire, so the container re-seeds directly from imageRef.

Also mirror handleFlip and drive the live preview with the original
bytes, so a stale rotated/flipped blob stops rendering after Reset.
Zoom now anchors on a fixed point (the crop-box center, captured when the
zoom value changes and frozen while the box moves/draws), so scaling keeps
that point on screen instead of drifting. The container owns the anchor and
passes it to both CropArea's transformOrigin and the export math.

Centralize the source-rect mapping into computeSourceRect, shared by
cropImage and PreviewPane, so exported pixels match the on-screen framing.
This also fixes a pre-existing off-center bug: the old pixelCrop.x / z math
assumed a top-left origin while the CSS used center, giving the wrong region
for any off-center crop. The read window is clamped into the image to guard
zoom-out (z < 1) from reading off-canvas.
@github-actions

This comment has been minimized.

The buttons rotate the image by ±90° (via rotateImage), so "flip" was the
wrong term. Rename it everywhere: the enableFlip XML property key becomes
enableRotation, the rotate-left/rotate-right icon assets, CropToolbar props
and aria-labels/titles, the container's handleRotate handler, and all
affected tests, comments, and generated typings.

Also polish the toolbar: render the "Zoom" label at regular weight (Atlas
styles labels bold), and rename the Reset button caption to "Enable reset".
The showResetButton property key is unchanged to avoid breaking existing
app bindings.
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@rahmanunver rahmanunver force-pushed the feat/image-cropper-polish-rotate-bw branch from 37dfe9b to 050b169 Compare July 6, 2026 13:45
@github-actions

This comment has been minimized.

@rahmanunver rahmanunver force-pushed the feat/image-cropper-polish-rotate-bw branch from 050b169 to f4ecce1 Compare July 6, 2026 14:23
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

AI Code Review

⚠️ Approved with suggestions — low-severity items only, safe to merge


What was reviewed

File Change
src/ImageCropper.xml Added studioProCategory, studioCategory, and three new boolean properties (enableRotation, enableGrayscale, showResetButton)
typings/ImageCropperProps.d.ts Three new props added; XML ↔ TS alignment confirmed
src/ImageCropper.editorConfig.ts getPreview simplified with helper functions; describeConfig extracted to utils
src/ImageCropper.editorPreview.tsx New design-mode StaticCropPreview renders real CropArea against a static image URL
src/components/CropArea.tsx Added onUserInteractStart, zoomAnchor, grayscale props; stray-crop guard; safeImageUri gating
src/components/CropToolbar.tsx New component: rotate, grayscale, zoom, reset controls
src/components/ImageCropperContainer.tsx Wired rotation, grayscale, reset handlers; useOriginalImage, usePreviewSrc hooks integrated
src/components/PreviewPane.tsx Replaced manual source-rect math with computeSourceRect; added grayscale canvas filter
src/hooks/useImageCropperState.ts Added grayscale / setGrayscale to shared state
src/hooks/useOriginalImage.ts New hook: captures original bytes via fetch for Reset
src/hooks/usePreviewSrc.ts New hook: manages blob: URL lifecycle for live rotation preview
src/utils/cropGuard.ts New: isStrayCrop threshold filter
src/utils/cropImage.ts Added computeSourceRect, ZoomAnchor, anchor-aware zoom math, grayscale option
src/utils/cropMapping.ts New: normalizeRotation, rotatedCanvasSize
src/utils/describeConfig.ts Extracted from editorConfig — shared by both editor surfaces
src/utils/initialCrop.ts Extracted buildInitialCrop from CropArea
src/utils/rotateImage.ts New: bakes rotation onto a canvas and returns a File
src/utils/safeImageUri.ts New: scheme allowlist guard against javascript:/vbscript: URIs
src/__tests__/ (5 files) New/updated integration tests for reset, grayscale, rotation, multi-instance
src/components/__tests__/ (3 files) New component-level tests for CropArea, CropToolbar, PreviewPane
src/hooks/__tests__/ (3 files) New hook tests
src/utils/__tests__/ (5 files) New/updated util tests
src/ui/ImageCropper.scss Toolbar, icon-button, editor-preview layout added
jest.config.js SVG transform override added
openspec/ New spec + config files

Skipped (out of scope): dist/, pnpm-lock.yaml, binary PNGs


Findings

⚠️ Low — usePreviewSrc mutates state during render (rule-of-hooks violation)

File: src/hooks/usePreviewSrc.ts lines 463–469
Problem: The comparison prevUri.current !== committedUri is evaluated and setPreviewSrc(undefined) is called directly in the function body, not inside a useEffect. This is a React rule-of-hooks violation: calling a state setter during render is only permitted in a tightly constrained "derived state" pattern, and here it triggers a second render on every uri change, which can surprise strict-mode React and cause double-invocation warnings. The pattern also calls revoke() (which touches a ref) during render, violating the "render must be pure" contract.

Fix: Move the uri-change side effect into a useEffect:

useEffect(() => {
    if (prevUri.current !== committedUri) {
        prevUri.current = committedUri;
        if (blobRef.current) {
            revoke();
            setPreviewSrc(undefined);
        }
    }
}, [committedUri, revoke]);

⚠️ Low — crossOrigin removed from crop image without explanation; re-adds CORS risk for remote images

File: src/components/CropArea.tsx line 174 (diff context: removal of crossOrigin="anonymous")
Problem: The prior version had crossOrigin="anonymous" on the <img>. Removing it means that for remote images the browser serves them without a CORS exchange, and canvas.toBlob() will fail with a tainted-canvas SecurityError when the image is on a different origin. safeImageUri blocks javascript: URIs but does not ensure CORS compliance — it only validates the scheme.
Fix: Either restore crossOrigin="anonymous" (with the safeImageUri guard already in place, this is safe for all allowed schemes), or document in a code comment why it was removed and how callers are expected to serve images with the right headers.


⚠️ Low — Reset button missing aria-label; relies solely on visible text

File: src/components/CropToolbar.tsx line 272
Problem: The rotate buttons use aria-label (good), but the Reset and Grayscale buttons use only visible text with a title tooltip. The title attribute is not reliably announced by screen readers. For consistency with the rotate buttons and the accessibility guidelines, every toolbar button should have an explicit aria-label.
Note: The Grayscale button also has aria-pressed, which is correct for a toggle — this is just about making the accessible name explicit so it doesn't depend on title.


⚠️ Low — Missing CHANGELOG entry for new runtime behavior

File: packages/pluggableWidgets/image-cropper-web/CHANGELOG.md (not changed)
Problem: Rotation, grayscale, reset, zoom-anchor fix, and multi-instance crop isolation are all user-visible behavioral additions. Per repo convention a CHANGELOG entry is required when runtime code or widget behavior changes. Version bump is out of scope, but the unreleased entry should be present.
Fix: Run pnpm -w changelog and add an entry under [Unreleased] documenting the new features.


⚠️ Low — StaticCropPreview uses createRef instead of useRef inside a function component

File: src/ImageCropper.editorPreview.tsx line 28
Problem: createRef creates a new ref object on every render. Inside a function component, useRef should always be used because it returns the same ref across re-renders.
Fix:

- const imageRef = createRef<HTMLImageElement>();
+ const imageRef = useRef<HTMLImageElement>(null);

Positives

  • safeImageUri blocks javascript:, vbscript:, and data:text/html URIs as defense-in-depth — security-conscious addition with corresponding tests covering all disallowed schemes.
  • onUserInteractStart / userDraggedRef pattern cleanly prevents layout-driven onCropComplete from auto-committing; the multi-instance test proves sibling isolation explicitly.
  • computeSourceRect is a single authoritative source for zoom-anchor math shared by cropImage and PreviewPane, ensuring the live preview and exported pixels agree — a subtle but important correctness property.
  • useOriginalImage correctly guards against concurrent fetch races with the cancelled flag and skips re-capture of the widget's own baked outputs via markInternalChange — well-modeled lifecycle.
  • usePreviewSrc revokes blob URLs on both uri-change and unmount, preventing memory leaks.
  • Test coverage is extensive: 103 tests covering reset, grayscale reversibility, multi-instance isolation, zoom-anchor math, and stray-crop guard.
  • Icon-only rotate buttons correctly use aria-label + title and <img alt=""> so the SVG is decorative — good accessibility hygiene.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants